Skip to content

feat: return-item-selection modal on order detail @W-22821837#3886

Merged
sf-jie-dai merged 15 commits into
feature/264-order-managementfrom
jie.dai-W-22821837-returnItemSelectionModal
Jun 22, 2026
Merged

feat: return-item-selection modal on order detail @W-22821837#3886
sf-jie-dai merged 15 commits into
feature/264-order-managementfrom
jie.dai-W-22821837-returnItemSelectionModal

Conversation

@sf-jie-dai

@sf-jie-dai sf-jie-dai commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Context

Phase 2 of the PWA Kit item-level return flow for registered shoppers. Today's Return Items button on the order-detail page is a disabled W-22821836 placeholder with a "Returns coming soon" hint. This PR makes it functional: clicking it opens a Chakra modal where the shopper picks which items to return, sets quantity, and chooses a reason from getOmsMetaData. The follow-up review/submit step lives in W-22821838.

Summary

  • New app/components/return-items-modal/ — centered Modal at md+, bottom-sheet Drawer on base. Per-item bordered rows collapse to a checkbox + name + "Up to N units available to return"; expand to a quantity stepper (useNumberInput + Input, clamped to quantityAvailableToReturn) and a reason <Select> populated from useOmsMetaData().returnReasonCodes with the default: true entry pre-applied.
  • Selection state is owned by order-detail.jsx, not the modal, so the wrapper swap on viewport resize doesn't reset progress and so step 2 (W-22821838) can read the same payload directly. A defensive useEffect retro-fills the OMS default reason on rows that were checked before metadata resolved.
  • Return Items CTA flipped from disabled to live; visible label updated from Start return to Return Items (message id and data-testid kept stable so downstream extenders aren't broken). The CTA gates on customer ownership of the order (order.customerInfo.customerId === customerId) plus the per-item quantityAvailableToReturn signal — same defense-in-depth as the cancel-order CTA.
  • buildReturnProductItems(selection, defaultReasonCode) exported for reuse by W-22821838's submit step. Returns the productItems array shape from OmsReturnOrderRequest: quantity serialized as a JS Number per oms.yaml's format: double, reason omitted when the shopper kept the OMS default so the server applies it. Drops malformed rows (non-finite or non-positive quantity) defensively.
  • A11y: Modal/Drawer surfaces are labelled by header; quantity +/- buttons and the Quantity / Reason controls carry contextual aria-labels including the product name; loading state wrapped in role="status" with a visually-hidden announcement; the disabled Review return button uses a useId()-generated aria-describedby hint explaining why.

return items button:
Screenshot 2026-06-18 at 3 20 47 PM

return modal:
Screenshot 2026-06-18 at 3 20 36 PM

Test plan

  • pnpm test — 12 modal tests + 100 page-level tests pass (covers selection validation, default-reason apply, quantity clamp, payload shape, ownership-mismatch hide, click-opens-modal, loading + error UX, retro-fill on metadata resolve)
  • Lint clean
  • Manual against zymc-002 sandbox (build 26.7.0.166-master, SOM-wired): order detail with at least one shipped item shows the Return Items button; clicking opens the modal, item rows render with variation summary, default reason pre-selected, quantity clamps to max, Review return enables only when valid
  • Resize across the md breakpoint while modal is open — selection survives the Modal ↔ Drawer wrapper swap
  • Voice-control test: speaking "Return Items" activates the trigger (visible label = accessible name)

sf-jie-dai and others added 6 commits June 18, 2026 07:54
Step 1 of the item-level return flow per the storefront-next 2026-06-17
designs. Renders as a centered Chakra Modal at md+ and a bottom-sheet
Drawer on base (mobile), with returnable items as collapsed rows that
expand into a Quantity stepper + Reason <Select> when checked.

Selection is parent-owned (`selection` + `onSelectionChange` props) so
the wrapper swap on viewport resize doesn't reset the shopper's
progress, and so step 2's review modal (W-22821838) can read the same
payload without prop-drilling.

Reason codes come from `useOmsMetaData().returnReasonCodes`; the entry
with `default: true` is auto-applied when a row is first toggled on.
Loading state renders skeleton rows; failure renders an inline alert
with a Retry button that calls `query.refetch()`.

Quantity uses `useNumberInput` + a styled `Input` (the existing repo
pattern — Chakra's `NumberInput` is not exported from the shared UI),
clamping on blur to the line item's `quantityAvailableToReturn`.

`buildReturnPayload(selection, defaultReasonCode)` shapes the API
request that step 2 will POST: `productItems: [{itemId, quantity, reason?}]`
matching `OmsReturnOrderRequest`. Quantity is serialized as a JS Number
(per oms.yaml `format: double`); reason is omitted when the shopper
kept the OMS default, so the server applies the default per the API
contract (confirmed by Mathias Stödtler in the OMS API thread,
2026-06-10).

Includes 10 Jest tests covering header rendering, selection validation,
default-reason auto-apply, quantity clamping, payload shape (Number
serialization + reason omission), Cancel handler, loading state, and
error+Retry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flips the W-22821836 placeholder CTA from disabled to live: clicking it
now opens the return-order-modal added in the previous commit. The
button's visible label updates from "Start return" to "Return Items" to
match the storefront-next designs, but the message id and `data-testid`
stay stable so downstream extenders are not broken.

Drops the `aria-describedby`/`VisuallyHidden`/`title` "Returns coming
soon" plumbing (along with the unused `start_return_disabled_explanation`
message) — they were specifically for the placeholder state.

Added state on the page:
- second `useDisclosure` for the return modal, alongside the existing
  cancel-order one
- `returnSelection` held in `useState`, lifted to the page so the modal
  can swap between Modal and Drawer on viewport resize without losing
  shopper progress, and so step 2's review modal (W-22821838) can read
  the same payload directly
- a gated `useProducts` (only fires when modal is open and there is at
  least one returnable line) merged into `enrichedReturnableItems` so
  the modal can render "<Name> — <Variation>" lines via
  `getDisplayVariationValues`

`handleCloseReturnModal` resets the local selection on close, mirroring
the cancel-order modal's reset semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the W-22821836 placeholder assertions (disabled button +
"Returns coming soon" accessible description + matching `title`) with
checks for the now-enabled state and the renamed visible label.
The `data-testid` and message id are stable, so the test still locates
the button via `account-order-detail-start-return`.

Adds a click-opens-modal smoke test asserting the modal header
"Return items from order #..." appears after clicking the trigger.

Renamed the describe block from "Start Return CTA (W-22821836)" to
"Return Items CTA (W-22821836 / W-22821837)" to reflect that both WIs
contribute to the behavior covered here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ran `npm run extract-default-translations` and `npm run compile-translations`
after the string changes:
- new `return_order_modal.*` messages (12 entries) for the modal
- updated `defaultMessage` for `account_order_detail.button.start_return`
  from "Start return" to "Return Items"
- removed the unused `account_order_detail.button.start_return_disabled_explanation`

Tests via `app/utils/test-utils.js` consume the compiled JSON, so the
generated files under `app/static/translations/compiled/` must be
committed — a regen alone leaves test snapshots stale.

Added a one-line CHANGELOG entry under v10.1.0-dev.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the folder, component, message-id namespace, and data-testid
prefix with the W-22821838 plan, which assumes a single modal stack
named `return-items-modal/` containing both the selection view (this
WI) and the upcoming review view. Keeping the original
`return-order-modal/` name would have forced W-22821838 to either move
the directory mid-feature or add a same-purpose sibling — neither
helpful for reviewers tracking the two WIs together.

Changes:
- `app/components/return-order-modal/` → `app/components/return-items-modal/`
- `ReturnOrderModal` symbol → `ReturnItemsModal`
- `data-testid="return-modal-*"` → `data-testid="return-items-modal-*"`
  (the page-level trigger `account-order-detail-start-return` is
  unchanged because downstream extenders depend on it via ccExtensibility)
- `return_order_modal.*` message ids → `return_items_modal.*`
- Regenerated `translations/` and `app/static/translations/compiled/`

Tests: all 10 modal tests + 99 orders.test.js tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight fixes from the dual Cursor + Claude review pass.

**Ownership guard on Return Items CTA** (order-detail.jsx)
The cancel-order CTA gates on `order.customerInfo.customerId === customerId`
as defense-in-depth against a useOrder response somehow returning an order
the registered shopper does not own; the return CTA was missing the same
check. Added it, plus a covering test that asserts the trigger hides when
ownership doesn't match.

**Rename helper for an unambiguous call site** (constants.js)
`buildReturnPayload` returned the bare productItems array but its JSDoc
referenced `OmsReturnOrderRequest = {productItems: [...]}`, easy to
misread. Renamed to `buildReturnProductItems` so callers wrap it
explicitly: `body: {productItems: buildReturnProductItems(...)}`. Drop-in
for W-22821838's submission step.

**Defensive payload filter** (constants.js)
`Number(row.quantity)` returned `0` for empty strings and `NaN` for
non-numerics. The UI gates on `isSelectionValid`, but the helper is
exported for reuse by W-22821838 and exercised standalone in tests, so
hardened it: rows with non-positive or non-finite quantity are dropped.
Test added.

**Contextual a11y labels** (index.jsx, constants.js)
The +/- stepper buttons rendered as bare "−"/"+", and Quantity / Reason
form controls labelled identically across rows so screen-reader users
couldn't tell rows apart. Added `messages.quantityFor`, `reasonFor`,
`quantityIncrement`, `quantityDecrement` and threaded `productName` down
to QuantityField. Visible FormLabels stay short ("Quantity", "Reason");
contextual labels live on `aria-label`.

**`role="status"` + visible-or-hidden loading announcement** (index.jsx)
Skeleton block now wrapped in `role="status"` with a `VisuallyHidden`
"Loading return reasons…" message (the `loadingReasons` entry in the
catalog was previously unused).

**`useId()` for the disabled-hint id** (index.jsx)
Replaced the hardcoded `'return-items-modal-review-disabled-hint'` string
with `useId()` so two modal instances on the same page wouldn't collide.

**Drop the no-op ICU plurals** (constants.js)
`availableToReturn` and `itemCheckboxLabel` declared `{count, plural,
one {…} other {…}}` with identical text in both branches — translators
would flag this. Differentiated the strings ("# unit" vs "# units").

**Backfill regression test** (index.test.js)
Added a test for the `didBackfillRef` retro-fill path: a row pre-checked
without a reasonCode (e.g. selection rehydrated from URL state in a
future WI) gets the OMS default applied on mount.

Tests: 112 pass (12 modal + 100 orders).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sf-jie-dai sf-jie-dai requested a review from a team as a code owner June 18, 2026 11:56
@git2gus

git2gus Bot commented Jun 18, 2026

Copy link
Copy Markdown

Git2Gus App is installed but the .git2gus/config.json doesn't have right values. You should add the required configuration.

@cc-prodsec

cc-prodsec commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@sf-jie-dai sf-jie-dai self-assigned this Jun 18, 2026
@sf-jie-dai sf-jie-dai added the skip changelog Skip the "Changelog Check" GitHub Actions step even if the Changelog.md files are not updated label Jun 18, 2026
sf-jie-dai and others added 2 commits June 18, 2026 08:01
Mirrors the W-22821836 cleanup (e382e89) — reviewers prefer the modal
work be tracked via the GUS WI rather than a separate CHANGELOG line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI runs `npm run build-translations` which chains
`extract-default-translations` + `compile-translations` +
`compile-translations:pseudo`, then the smoke-test job fails the
build if `git diff --exit-code` finds uncommitted changes. I had
only run the first two locally, leaving `en-XA.json` (the pseudo
locale) stale relative to the renamed `return_items_modal.*`
message ids and the new contextual a11y labels (`quantity_for`,
`reason_for`, `quantity_increment`, `quantity_decrement`).

Regenerated via `npm run build-translations`. No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
},
reasonsError: {
defaultMessage: 'We could not load the return reasons. Please try again.',
id: 'return_items_modal.text.reasons_error'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sf-jie-dai Did you get messages for various error scenarios from Sab or are we supposed to show a default message for all errors?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a W-22821839 especially for error states but I could ask Sab now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reached out here

but an error related to SCAPI. I think a generic one should be okay since it shouldn't happen often

@sf-madhuri-uppu

sf-madhuri-uppu commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

@sf-jie-dai
Claude review

🐛 Bug: Stale closure in updateRow — rapid checkbox toggling loses selections

File: app/components/return-items-modal/index.jsx (updateRow callback)

updateRow reads selection from the useCallback closure. If two checkboxes are toggled in the same React batch (before re-render), both spread the same stale selection — the second overwrites the first.

Repro: User quickly checks item-1 and item-2. Both handleToggle calls fire before re-render, both spread the same stale selection (empty {}). Only item-2 ends up in state.

Fix: Use a functional updater:

onSelectionChange(prev => ({...prev, [itemId]: {...(prev[itemId] || {}), ...patch}}))

This removes the selection dependency from the callback entirely.


♻️ Reuse: Duplicate QuantityField vs existing QuantityPicker

File: app/components/return-items-modal/index.jsx (QuantityField component)

The codebase already has app/components/quantity-picker/index.jsx wrapping useNumberInput with a11y improvements (focus-on-select for mobile, proper aria-labels). The new QuantityField reimplements this without those improvements.

Suggestion: Reuse QuantityPicker with min=1, max=quantityAvailableToReturn props. If it needs customization (e.g. contextual aria-labels with product name), extend it rather than duplicating.


🛡️ Robustness: useProducts with unbatched IDs (SCAPI 24-item limit)

File: app/pages/account/order-detail.jsx (useProducts call)

useProducts({parameters: {ids: returnableProductIds.join(',')}}) sends all product IDs in a single request. SCAPI's /products endpoint has a documented limit of 24 IDs per call.

Failure scenario: An order with 25+ line items (e.g. bulk corporate order) gets a 400 error or silent truncation, leaving some items without names/images in the modal.

Suggestion: Slice into batches of 24 or rely on the already-available productName from the order line item (avoiding the fetch entirely for display-name purposes).


⚡ Perf: O(n) row re-renders per keystroke

File: app/components/return-items-modal/index.jsx (updateRow deps)

updateRow has [selection, onSelectionChange] in its dependency array. Every selection change creates a new updateRow → new handleQuantityChange → new prop to every ReturnableItemRow → all rows re-render.

Impact: With 10+ returnable items on low-end mobile, typing in one quantity field causes visible lag.

Fix: Same as #1 — switching to a functional updater removes selection from deps. Alternatively, memoize ReturnableItemRow with React.memo.


📁 Organization: buildReturnProductItems location

File: app/components/return-items-modal/constants.js

This API payload shaper sits alongside i18n messages in a component's constants.js. The related getReturnableItems already lives in app/utils/return-utils.js. When W-22821838 imports this for the submit step, the path (return-items-modal/constants) conflates UI concerns with data-layer logic.

Suggestion: Move buildReturnProductItems to app/utils/return-utils.js next to getReturnableItems.

sf-jie-dai and others added 5 commits June 18, 2026 21:42
Switch updateRow and handleToggle to functional setState updaters so
two checkbox toggles dispatched in the same React batch both observe
the latest selection. Add a regression test that fires two toggles
inside a single act() and asserts both rows end up checked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the inline QuantityField in favor of the shared QuantityPicker
component, which already provides mobile-friendly select-on-focus,
keyboard activation on the +/- buttons, and i18n strings for the
increment/decrement aria-labels.

Remove the now-unused quantity_for, quantity_increment, and
quantity_decrement message ids from the return-items-modal catalog
and regenerate en-US, en-GB, and en-XA translations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The OMS-expanded order already carries productName,
variationAttributes, and variationValues on each line item, so the
gated useProducts fetch added nothing the modal could not derive
locally. Removing it also avoids the SCAPI /products 24-id ceiling
that the call would have hit on large orders.

Pass returnableItems straight through to ReturnItemsModal — the
formatVariationSummary helper already reads variationAttributes /
variationValues directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrap ReturnableItemRow in React.memo so it skips re-rendering when
neither its item nor its row state changed. Move the inline arrow
wrappers (onToggle / onQuantityChange / onReasonChange) into useCallback
inside the row itself, and pass the parent's stable handlers straight
through, so the per-row props no longer change on every keystroke.

This compounds with the FIX-1 functional-updater change: now the parent
handlers depend only on [onSelectionChange, defaultReasonCode] and the
child handlers depend only on [parent handler, itemId], so unrelated
rows do not re-render while one row's quantity is being edited.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Relocate the helper out of return-items-modal/constants.js (which now
holds only intl message definitions) into the existing return-utils.js
alongside getReturnableItems. Update the modal import and migrate the
helper-only unit tests to return-utils.test.js so the modal test file
stays focused on UI behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sf-jie-dai

Copy link
Copy Markdown
Contributor Author

@sf-madhuri-uppu Thanks for the review — all 5 findings addressed in the latest push.

Summary

# Finding Resolution Commit
1 Stale closure in updateRow Switched updateRow and handleToggle to functional setState updaters; added a regression test that fires two checkbox toggles inside a single act() and asserts both rows end up checked. cddc1f3
2 Duplicate QuantityField vs QuantityPicker Dropped the inline QuantityField and reused app/components/quantity-picker. Removed the now-unused quantity_for / quantity_increment / quantity_decrement message ids from the modal catalog and regenerated en-US / en-GB / en-XA translations. 8bafd33
3 useProducts with unbatched IDs Dropped the gated useProducts call entirely. The OMS-expanded order already carries productName, variationAttributes, and variationValues on each line item, and formatVariationSummary reads those directly — no /products fetch needed, and no 24-ID ceiling to worry about. 59fc68c
4 O(n) row re-renders per keystroke Resolved by #1 (the parent handlers now depend only on [onSelectionChange, defaultReasonCode]). Also wrapped ReturnableItemRow in React.memo and moved its inline arrow wrappers into useCallback so unrelated rows don't re-render while one row's quantity is being edited — forward-compatible with the W-22821838 review view. ebc09cd
5 Organization of buildReturnProductItems Moved into app/utils/return-utils.js alongside getReturnableItems. constants.js now holds only intl messages. Helper-only unit tests migrated to return-utils.test.js; the modal test file stays focused on UI behavior. eb036e4

Scope note on error handling

The full per-error-code mapping (matching what your storefront-next PR 1911 layered in src/lib/order-management/errors.ts) is W-22821839 scope, not this WI. Per shopper-orders-oas, the oms-return-order endpoint can return InvalidReasonCodeErrorResponse, UnknownProductItemIdsErrorResponse, ReturnQuantityExceededErrorResponse, ResourceNotFoundException, OrderReturnFailedErrorResponse, OmsNotActiveErrorResponse, plus 5xx/network — each gets a distinct user-facing message and recovery affordance over in W-22821839. This PR (W-22821837) covers selection only; the in-flight Submit-return path lives in W-22821838 and the error matrix in W-22821839, so I haven't preemptively scaffolded OmsOperationError / per-code i18n strings here.

Lint and the full account/orders + return-items-modal + return-utils test suites are green locally. Let me know if you'd like any of the changes split differently or any of the new tests expanded.

…eturnItemSelectionModal

Resolves conflicts with #3883 (cancel-modal OMS-metadata polish) and
#3884 (cancel error scenarios) merged into the base branch:

- orders.test.js: adopt base's `waitFor` import (used by the new cancel
  error tests) and align the return-eligible order mock to the new
  `useCustomerId: () => 'testCustomerId'` mock that base introduced
  (previously the test used the JWT-derived id from test-utils).
- order-detail.jsx: prettier-fold the cancel-error description line and
  the `isDisabled` predicate after the cancelTerminal addition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
<Button
colorScheme="blue"
onClick={handleReview}
isDisabled={!reviewEnabled}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WI asks for the disabled state to be announced via aria-disabled. Chakra's isDisabled renders the native disabled attribute, which removes the button from the tab order — so a keyboard/SR user can't focus it and the aria-describedby hint below never gets announced. Consider aria-disabled (keep it focusable, guard the onClick) or Chakra's focusable-when-disabled pattern. Note the aria-describedby test passes on attribute-presence but doesn't verify the hint is reachable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 6e3174f. Switched the Review button from isDisabled to aria-disabled={!reviewEnabled} so it stays in the tab order while invalid, and handleReview now no-ops when invalid (focusable means clickable). Chakra's _disabled styling still applies since the pseudo matches [aria-disabled=true]. Updated the test to assert the aria-describedby hint is actually reachable (resolves to a node in the document carrying the explanation), not just present.

<Text fontSize="lg" fontWeight="bold">
<FormattedMessage {...messages.title} values={{orderNo: order?.orderNo}} />
</Text>
<Text fontSize="sm" color="gray.600" fontWeight="normal">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WI wants the modal described by the subhead (aria-describedby). Chakra auto-wires aria-labelledby to the header for the title, but the subhead isn't associated as the description. Giving this Text an id and setting aria-describedby on the ModalContent/DrawerContent would satisfy it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6e3174f. Heads-up on the mechanism: setting aria-describedby directly on ModalContent/DrawerContent is silently overridden by Chakra 2.7's getDialogProps, which unconditionally forces the dialog's aria-describedby to the ModalBody id whenever a body is mounted (verified by probing the rendered DOM). So instead I moved the subhead to lead the body — Chakra's auto aria-describedby now points at it, and the title stays in the header for aria-labelledby. This mirrors the cancel-order-modal pattern.

<DrawerOverlay />
<DrawerContent data-testid="return-items-modal-drawer">
<DrawerHeader pb={1}>{header}</DrawerHeader>
<DrawerCloseButton />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WI specifies the mobile bottom-sheet shows the standard drag handle with no close X, but this renders a DrawerCloseButton. Chakra's Drawer has no built-in drag handle (and the product-list bottom Drawer also uses DrawerCloseButton), so — intended deviation, or should this drop the X and add a handle?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the design spec (storefront-next#2038, the 2026-06-18 rewrite that matches the latest WI-3). It doesn't specify a drag handle anywhere, and the WI-3 section says to reuse the cancel-order-modal pattern with ModalCloseButton. So I'm keeping DrawerCloseButton here — it's spec-aligned and consistent with the existing product-list bottom Drawer. The "standard drag handle (no close X)" line in the GUS WI text looks stale relative to the spec; happy to revisit if design confirms otherwise.

Comment on lines +1 to +224
/*
* Copyright (c) 2026, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React, {useState} from 'react'
import PropTypes from 'prop-types'
import {act, fireEvent, screen, waitFor, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
import ReturnItemsModal from '@salesforce/retail-react-app/app/components/return-items-modal'

let mockOmsMetaData = {
data: {
cancelReasonCodes: [],
returnReasonCodes: [
{reason: 'Wrong size', default: true},
{reason: 'Defect', default: false},
{reason: 'Changed my mind', default: false}
]
},
isLoading: false,
isError: false,
refetch: jest.fn()
}
jest.mock('@salesforce/commerce-sdk-react', () => {
const actual = jest.requireActual('@salesforce/commerce-sdk-react')
return {
...actual,
useOmsMetaData: () => mockOmsMetaData
}
})

const baseOrder = {
orderNo: '00123456',
productItems: [
{
itemId: 'item-1',
productId: 'prod-1',
productName: 'Cotton Crew T-Shirt',
quantity: 2,
omsData: {quantityAvailableToReturn: 2},
variationAttributes: [
{id: 'color', name: 'Color', values: [{value: 'BLACK', name: 'Black'}]},
{id: 'size', name: 'Size', values: [{value: 'M', name: 'M'}]}
],
variationValues: {color: 'BLACK', size: 'M'}
},
{
itemId: 'item-2',
productId: 'prod-2',
productName: 'Slim Fit Chino Pants',
quantity: 1,
omsData: {quantityAvailableToReturn: 1}
}
]
}

const Harness = ({onReview = jest.fn(), onClose = jest.fn(), initialSelection = {}} = {}) => {
const [selection, setSelection] = useState(initialSelection)
return (
<ReturnItemsModal
isOpen={true}
onClose={onClose}
order={baseOrder}
returnableItems={baseOrder.productItems}
selection={selection}
onSelectionChange={setSelection}
onReview={onReview}
/>
)
}
Harness.propTypes = {
onReview: PropTypes.func,
onClose: PropTypes.func,
initialSelection: PropTypes.object
}

afterEach(() => {
mockOmsMetaData = {
data: {
cancelReasonCodes: [],
returnReasonCodes: [
{reason: 'Wrong size', default: true},
{reason: 'Defect', default: false},
{reason: 'Changed my mind', default: false}
]
},
isLoading: false,
isError: false,
refetch: jest.fn()
}
jest.clearAllMocks()
})

test('renders header with order number and a row per returnable item', async () => {
renderWithProviders(<Harness />)
expect(await screen.findByText(/return items from order #00123456/i)).toBeInTheDocument()
expect(screen.getByText(/select the items you want to return/i)).toBeInTheDocument()
expect(screen.getAllByTestId('return-items-modal-item-row')).toHaveLength(2)
expect(screen.getByText(/cotton crew t-shirt/i)).toBeInTheDocument()
expect(screen.getByText(/slim fit chino pants/i)).toBeInTheDocument()
})

test('Review return is disabled until at least one valid row is selected', async () => {
const user = userEvent.setup()
renderWithProviders(<Harness />)
const reviewButton = screen.getByTestId('return-items-modal-review')
expect(reviewButton).toBeDisabled()
expect(reviewButton).toHaveAttribute('aria-describedby')

const checkboxes = screen.getAllByRole('checkbox')
await user.click(checkboxes[0])
expect(reviewButton).toBeEnabled()
expect(reviewButton).not.toHaveAttribute('aria-describedby')
})

test('toggling a row expands it and pre-selects the OMS default reason', async () => {
const user = userEvent.setup()
renderWithProviders(<Harness />)
const checkboxes = screen.getAllByRole('checkbox')
await user.click(checkboxes[0])

const row = screen.getAllByTestId('return-items-modal-item-row')[0]
// Reason carries a per-row aria-label (so screen readers can distinguish
// the dropdowns). Quantity reuses the shared QuantityPicker which sets
// aria-label="Quantity"; we scope to the row + the input element to
// disambiguate from the +/- buttons.
expect(within(row).getByLabelText(/reason for /i, {selector: 'select'})).toHaveValue(
'Wrong size'
)
expect(within(row).getByLabelText(/^quantity$/i, {selector: 'input'})).toHaveValue('1')
})

test('quantity field clamps to the available-to-return ceiling', async () => {
const user = userEvent.setup()
renderWithProviders(<Harness />)
const checkboxes = screen.getAllByRole('checkbox')
await user.click(checkboxes[0]) // item-1 has max 2

const row = screen.getAllByTestId('return-items-modal-item-row')[0]
const qty = within(row).getByLabelText(/^quantity$/i, {selector: 'input'})
// Set value directly then blur — Chakra's useNumberInput clamps on blur,
// and userEvent.clear() doesn't propagate to a controlled NumberInput
// because the hook rejects empty intermediate values.
fireEvent.change(qty, {target: {value: '99'}})
fireEvent.blur(qty)
expect(qty).toHaveValue('2')
})

test('Cancel calls onClose', async () => {
const user = userEvent.setup()
const onClose = jest.fn()
renderWithProviders(<Harness onClose={onClose} />)
await user.click(screen.getByTestId('return-items-modal-cancel'))
expect(onClose).toHaveBeenCalledTimes(1)
})

test('clicking Review return forwards a properly shaped payload', async () => {
const user = userEvent.setup()
const onReview = jest.fn()
renderWithProviders(<Harness onReview={onReview} />)

const checkboxes = screen.getAllByRole('checkbox')
await user.click(checkboxes[1]) // item-2: max 1, should default-reason

// Change reason away from default so it gets serialized
const reason = within(screen.getAllByTestId('return-items-modal-item-row')[1]).getByLabelText(
/reason for /i,
{selector: 'select'}
)
await user.selectOptions(reason, 'Defect')

await user.click(screen.getByTestId('return-items-modal-review'))
expect(onReview).toHaveBeenCalledWith([{itemId: 'item-2', quantity: 1, reason: 'Defect'}])
})

test('renders skeleton placeholders while OMS metadata is loading', () => {
mockOmsMetaData = {data: undefined, isLoading: true, isError: false, refetch: jest.fn()}
renderWithProviders(<Harness />)
expect(screen.getByTestId('return-items-modal-loading')).toBeInTheDocument()
})

test('backfills the OMS default reason on already-checked rows when metadata is available', async () => {
// Models the case where the parent has a checked row whose reasonCode
// was never set (e.g. selection rehydrated from URL state in a future
// WI, or initial open where the user clicked the row before reasons
// resolved). The modal's mount-time backfill effect must apply the
// OMS default so the row is valid without forcing a re-pick.
const initial = {'item-1': {checked: true, quantity: 1, reasonCode: undefined}}

renderWithProviders(<Harness initialSelection={initial} />)

await waitFor(() => expect(screen.getByTestId('return-items-modal-review')).toBeEnabled())
const row = screen.getAllByTestId('return-items-modal-item-row')[0]
expect(within(row).getByLabelText(/reason for /i, {selector: 'select'})).toHaveValue(
'Wrong size'
)
})

test('two toggles in the same React batch both stick (no stale closure)', async () => {
// Regression: updateRow used to close over `selection`, so two checkbox
// toggles dispatched in a single render cycle each spread the same stale
// object — only the second won, silently dropping the first row.
renderWithProviders(<Harness />)
const [first, second] = screen.getAllByRole('checkbox')
act(() => {
fireEvent.click(first)
fireEvent.click(second)
})
await waitFor(() => expect(first).toBeChecked())
expect(second).toBeChecked()
})

test('renders an error alert + Retry when OMS metadata fails', async () => {
const user = userEvent.setup()
const refetch = jest.fn()
mockOmsMetaData = {data: undefined, isLoading: false, isError: true, refetch}
renderWithProviders(<Harness />)
expect(screen.getByTestId('return-items-modal-error')).toBeInTheDocument()
await user.click(screen.getByTestId('return-items-modal-retry'))
expect(refetch).toHaveBeenCalledTimes(1)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WI lists "focus return on close" and "axe-core baseline clean" as test ACs; neither is here (and there's no jest-axe in the repo yet). The suite also only renders the desktop Modal branch — the mobile Drawer isn't exercised. Worth a focus-return assertion + an axe baseline (and ideally a Drawer-branch render).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 6e3174f: a focus-return-on-close assertion and a mobile Drawer-branch render test (mocking useBreakpointValue). Holding off on the axe-core baseline for now — it needs a new jest-axe devDependency, and axe coverage is explicitly scoped to WI-6 (W-22821844, "A11y, i18n, responsive polish"), so I'll introduce the dep + baseline there rather than here. Suite is green at 12 tests.

@sf-shikhar-prasoon sf-shikhar-prasoon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks solid overall — the prior review's items (functional updaters, QuantityPicker reuse, dropped useProducts, memoization, helper relocation) are all in, the merge with the cancel PRs is clean, and the modal tests are thorough. A few accessibility points against the WI's a11y ACs, plus two test-coverage gaps the WI calls out. None blocking.

a11y (vs the WI's explicit ACs):

  • isDisabled vs aria-disabled on "Review return": Chakra's isDisabled renders the native disabled attribute, which drops the button from the tab order — so the aria-describedby hint can't actually be reached/announced by a keyboard or screen-reader user. The WI asks for aria-disabled specifically so the control stays focusable and the hint is announced. (The test asserting the attribute exists passes but doesn't prove it's reachable.)
  • Subhead isn't wired as the modal's description: Chakra auto-wires aria-labelledby to the header (title), but the subhead isn't associated via aria-describedby on the Modal/Drawer. The WI asks for "described by subhead." An id on the subhead + aria-describedby on the content would close it.
  • Mobile bottom-sheet renders a close-X: the WI specifies a drag handle with no close X on mobile, but the Drawer renders DrawerCloseButton. (Noting Chakra's Drawer has no built-in drag handle, and the product-list bottom Drawer also uses DrawerCloseButton — so flag whether this is an intended deviation.)

tests (WI lists both as ACs): no "focus return on close" test and no axe-core baseline (the repo has no axe testing today). The suite also only exercises the desktop Modal branch — the mobile Drawer is untested.

Non-blocking, for the W-22821838 reuse: buildReturnProductItems is exported for the submit step but doesn't clamp quantity to quantityAvailableToReturn (only the UI does), and omits the reason whenever reasonCode === defaultReasonCode — which sends an explicit reason for everything if defaultReasonCode is ever undefined. Both are unreachable through this PR's UI, but worth hardening since the helper is the shared seam. Minor: row max uses ?? 1 while isSelectionValid uses ?? 0 (dead branch given the getReturnableItems filter, but worth matching).

…21837

Address review feedback on the return-item-selection modal:

- Review button: use aria-disabled instead of isDisabled so the button
  stays in the tab order while invalid, letting keyboard/SR users reach
  the aria-describedby hint that explains why it's disabled. Chakra's
  _disabled styling still applies (pseudo matches [aria-disabled=true]);
  handleReview no-ops when invalid.
- Subhead is now the modal's accessible description: moved it to lead the
  ModalBody/DrawerBody so Chakra's auto aria-describedby (-> body id)
  points at it. Setting aria-describedby on ModalContent is silently
  overridden by Chakra's getDialogProps, so this mirrors cancel-order-modal.
- Tests: assert the disabled hint is reachable (not just present), add a
  focus-return-on-close assertion, and exercise the mobile Drawer branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sf-jie-dai sf-jie-dai merged commit 1425fd8 into feature/264-order-management Jun 22, 2026
41 of 42 checks passed
@sf-jie-dai sf-jie-dai deleted the jie.dai-W-22821837-returnItemSelectionModal branch June 22, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip changelog Skip the "Changelog Check" GitHub Actions step even if the Changelog.md files are not updated

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants